/*
 * PluginManager.java 24 oct. 2008
 *
 * Sweet Home 3D, Copyright (c) 2008 Emmanuel PUYBARET / eTeks <info@eteks.com>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */
package com.eteks.sweethome3d.plugin;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileFilter;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Modifier;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.TreeMap;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import javax.swing.undo.UndoableEditSupport;

import com.eteks.sweethome3d.model.CollectionEvent;
import com.eteks.sweethome3d.model.CollectionListener;
import com.eteks.sweethome3d.model.Home;
import com.eteks.sweethome3d.model.HomeApplication;
import com.eteks.sweethome3d.model.Library;
import com.eteks.sweethome3d.model.RecorderException;
import com.eteks.sweethome3d.model.UserPreferences;
import com.eteks.sweethome3d.tools.OperatingSystem;
import com.eteks.sweethome3d.viewcontroller.HomeController;

/**
 * Sweet Home 3D plug-ins manager.
 * @author Emmanuel Puybaret
 */
public class PluginManager
{
	public static final String PLUGIN_LIBRARY_TYPE = "Plugin";
	
	private static final String ID = "id";
	private static final String NAME = "name";
	private static final String CLASS = "class";
	private static final String DESCRIPTION = "description";
	private static final String VERSION = "version";
	private static final String LICENSE = "license";
	private static final String PROVIDER = "provider";
	private static final String APPLICATION_MINIMUM_VERSION = "applicationMinimumVersion";
	private static final String JAVA_MINIMUM_VERSION = "javaMinimumVersion";
	
	private static final String APPLICATION_PLUGIN_FAMILY = "ApplicationPlugin";
	
	private static final String DEFAULT_APPLICATION_PLUGIN_PROPERTIES_FILE = APPLICATION_PLUGIN_FAMILY + ".properties";
	
	private final File[] pluginFolders;
	private final Map<String, PluginLibrary> pluginLibraries = new TreeMap<String, PluginLibrary>();
	private final Map<Home, List<Plugin>> homePlugins = new LinkedHashMap<Home, List<Plugin>>();
	
	/**
	 * Reads application plug-ins from resources in the given plug-in folder.
	 */
	public PluginManager(File pluginFolder)
	{
		this(new File[] { pluginFolder });
	}
	
	/**
	 * Reads application plug-ins from resources in the given plug-in folders.
	 * @since 3.0
	 */
	public PluginManager(File[] pluginFolders)
	{
		this.pluginFolders = pluginFolders;
		if (pluginFolders != null)
		{
			for (File pluginFolder : pluginFolders)
			{
				// Try to load plugin files from plugin folder
				File[] pluginFiles = pluginFolder.listFiles(new FileFilter()
				{
					public boolean accept(File pathname)
					{
						return pathname.isFile();
					}
				});
				
				if (pluginFiles != null)
				{
					// Treat plug in files in reverse order of their version number
					Arrays.sort(pluginFiles, Collections.reverseOrder(OperatingSystem.getFileVersionComparator()));
					for (File pluginFile : pluginFiles)
					{
						try
						{
							loadPlugins(pluginFile.toURI().toURL(), pluginFile.getAbsolutePath());
						}
						catch (MalformedURLException ex)
						{
							// Files are supposed to exist !
						}
					}
				}
			}
		}
	}
	
	/**
	 * Reads application plug-ins from resources in the given URLs.
	 */
	public PluginManager(URL[] pluginUrls)
	{
		this.pluginFolders = null;
		for (URL pluginUrl : pluginUrls)
		{
			loadPlugins(pluginUrl, pluginUrl.toExternalForm());
		}
	}
	
	/**
	 * Loads the plug-ins that may be available in the given URL.
	 */
	private void loadPlugins(URL pluginUrl, String pluginLocation)
	{
		ZipInputStream zipIn = null;
		try
		{
			// Open a zip input from pluginUrl
			zipIn = new ZipInputStream(pluginUrl.openStream());
			// Try do find a plugin properties file in current zip stream  
			for (ZipEntry entry; (entry = zipIn.getNextEntry()) != null;)
			{
				String zipEntryName = entry.getName();
				int lastIndex = zipEntryName.lastIndexOf(DEFAULT_APPLICATION_PLUGIN_PROPERTIES_FILE);
				if (lastIndex != -1 && (lastIndex == 0 || zipEntryName.charAt(lastIndex - 1) == '/'))
				{
					try
					{
						// Build application plugin family with its package 
						String applicationPluginFamily = zipEntryName.substring(0, lastIndex);
						applicationPluginFamily += APPLICATION_PLUGIN_FAMILY;
						ClassLoader classLoader = new URLClassLoader(new URL[] { pluginUrl },
								getClass().getClassLoader());
						readPlugin(ResourceBundle.getBundle(applicationPluginFamily, Locale.getDefault(), classLoader),
								pluginLocation, "jar:" + pluginUrl.toString() + "!/"
										+ URLEncoder.encode(zipEntryName, "UTF-8").replace("+", "%20"),
								classLoader);
					}
					catch (MissingResourceException ex)
					{
						// Ignore malformed plugins
					}
				}
			}
		}
		catch (IOException ex)
		{
			// Ignore furniture plugin 
		}
		finally
		{
			if (zipIn != null)
			{
				try
				{
					zipIn.close();
				}
				catch (IOException ex)
				{}
			}
		}
	}
	
	/**
	 * Reads the plug-in properties from the given <code>resource</code>.
	 */
	private void readPlugin(ResourceBundle resource, String pluginLocation, String pluginEntry,
			ClassLoader pluginClassLoader)
	{
		try
		{
			String name = resource.getString(NAME);
			
			// Check Java and application versions
			String javaMinimumVersion = resource.getString(JAVA_MINIMUM_VERSION);
			if (!OperatingSystem.isJavaVersionGreaterOrEqual(javaMinimumVersion))
			{
				System.err.println("Invalid plug-in " + pluginEntry + ":\n" + "Not compatible Java version "
						+ System.getProperty("java.version"));
				return;
			}
			
			String applicationMinimumVersion = resource.getString(APPLICATION_MINIMUM_VERSION);
			if (!isApplicationVersionSuperiorTo(applicationMinimumVersion))
			{
				System.err.println("Invalid plug-in " + pluginEntry + ":\n" + "Not compatible application version");
				return;
			}
			
			String pluginClassName = resource.getString(CLASS);
			Class<? extends Plugin> pluginClass = getPluginClass(pluginClassLoader, pluginClassName);
			
			String id = getOptionalString(resource, ID, null);
			String description = resource.getString(DESCRIPTION);
			String version = resource.getString(VERSION);
			String license = resource.getString(LICENSE);
			String provider = resource.getString(PROVIDER);
			
			// Store plug-in properties if they don't exist yet
			if (this.pluginLibraries.get(name) == null)
			{
				this.pluginLibraries.put(name, new PluginLibrary(pluginLocation, id, name, description, version,
						license, provider, pluginClass, pluginClassLoader));
			}
		}
		catch (MissingResourceException ex)
		{
			System.err.println("Invalid plug-in " + pluginEntry + ":\n" + ex.getMessage());
		}
		catch (IllegalArgumentException ex)
		{
			System.err.println("Invalid plug-in " + pluginEntry + ":\n" + ex.getMessage());
		}
	}
	
	/**
	 * Returns the value of the property with the given <code>key</code> or the default value 
	 * if the property isn't defined.
	 */
	private String getOptionalString(ResourceBundle resource, String key, String defaultValue)
	{
		try
		{
			return resource.getString(key);
		}
		catch (MissingResourceException ex)
		{
			return defaultValue;
		}
	}
	
	/**
	 * Returns <code>true</code> if the given version is smaller than the version 
	 * of the application. Versions are compared only on their first two parts.
	 */
	private boolean isApplicationVersionSuperiorTo(String applicationMinimumVersion)
	{
		String[] applicationMinimumVersionParts = applicationMinimumVersion.split("\\.|_|\\s");
		if (applicationMinimumVersionParts.length >= 1)
		{
			try
			{
				// Compare digits in first part
				int applicationVersionFirstPart = (int) (Home.CURRENT_VERSION / 1000);
				int applicationMinimumVersionFirstPart = Integer.parseInt(applicationMinimumVersionParts[0]);
				if (applicationVersionFirstPart > applicationMinimumVersionFirstPart)
				{
					return true;
				}
				else if (applicationVersionFirstPart == applicationMinimumVersionFirstPart
						&& applicationMinimumVersionParts.length >= 2)
				{
					// Compare digits in second part
					return ((Home.CURRENT_VERSION / 100) % 10) >= Integer.parseInt(applicationMinimumVersionParts[1]);
				}
			}
			catch (NumberFormatException ex)
			{}
		}
		return false;
	}
	
	/**
	 * Returns the <code>Class</code> instance of the class named <code>pluginClassName</code>,
	 * after checking plug-in class exists, may be instantiated and has a default public constructor.
	 */
	@SuppressWarnings("unchecked")
	private Class<? extends Plugin> getPluginClass(ClassLoader pluginClassLoader, String pluginClassName)
	{
		try
		{
			Class<? extends Plugin> pluginClass = (Class<? extends Plugin>) pluginClassLoader
					.loadClass(pluginClassName);
			if (!Plugin.class.isAssignableFrom(pluginClass))
			{
				throw new IllegalArgumentException(pluginClassName + " not a subclass of " + Plugin.class.getName());
			}
			else if (Modifier.isAbstract(pluginClass.getModifiers()) || !Modifier.isPublic(pluginClass.getModifiers()))
			{
				throw new IllegalArgumentException(pluginClassName + " not a public static class");
			}
			Constructor<? extends Plugin> constructor = pluginClass.getConstructor(new Class[0]);
			if (!Modifier.isPublic(constructor.getModifiers()))
			{
				throw new IllegalArgumentException(pluginClassName + " constructor not accessible");
			}
			return pluginClass;
		}
		catch (NoClassDefFoundError ex)
		{
			throw new IllegalArgumentException(ex.getMessage(), ex);
		}
		catch (ClassNotFoundException ex)
		{
			throw new IllegalArgumentException(ex.getMessage(), ex);
		}
		catch (NoSuchMethodException ex)
		{
			throw new IllegalArgumentException(ex.getMessage(), ex);
		}
	}
	
	/**
	 * Returns the available plug-in libraries.
	 * @since 4.0
	 */
	public List<Library> getPluginLibraries()
	{
		return Collections.unmodifiableList(new ArrayList<Library>(this.pluginLibraries.values()));
	}
	
	/**
	 * Returns an unmodifiable list of plug-in instances initialized with the 
	 * given parameters.
	 */
	public List<Plugin> getPlugins(final HomeApplication application, final Home home, UserPreferences preferences,
			UndoableEditSupport undoSupport)
	{
		return getPlugins(application, home, preferences, null, undoSupport);
	}
	
	/**
	 * Returns an unmodifiable list of plug-in instances initialized with the 
	 * given parameters.
	 * @since 3.5
	 */
	List<Plugin> getPlugins(final HomeApplication application, final Home home, UserPreferences preferences,
			HomeController homeController, UndoableEditSupport undoSupport)
	{
		if (application.getHomes().contains(home))
		{
			List<Plugin> plugins = this.homePlugins.get(home);
			if (plugins == null)
			{
				plugins = new ArrayList<Plugin>();
				// Instantiate each plug-in class
				for (PluginLibrary pluginLibrary : this.pluginLibraries.values())
				{
					try
					{
						Plugin plugin = pluginLibrary.getPluginClass().newInstance();
						plugin.setPluginClassLoader(pluginLibrary.getPluginClassLoader());
						plugin.setName(pluginLibrary.getName());
						plugin.setDescription(pluginLibrary.getDescription());
						plugin.setVersion(pluginLibrary.getVersion());
						plugin.setLicense(pluginLibrary.getLicense());
						plugin.setProvider(pluginLibrary.getProvider());
						plugin.setUserPreferences(preferences);
						plugin.setHome(home);
						plugin.setHomeController(homeController);
						plugin.setUndoableEditSupport(undoSupport);
						plugins.add(plugin);
					}
					catch (InstantiationException ex)
					{
						// Shouldn't happen : plug-in class was checked during readPlugin call
						throw new RuntimeException(ex);
					}
					catch (IllegalAccessException ex)
					{
						// Shouldn't happen : plug-in class was checked during readPlugin call
						throw new RuntimeException(ex);
					}
				}
				
				plugins = Collections.unmodifiableList(plugins);
				this.homePlugins.put(home, plugins);
				
				// Add a listener that will destroy all plug-ins when home is deleted
				application.addHomesListener(new CollectionListener<Home>()
				{
					public void collectionChanged(CollectionEvent<Home> ev)
					{
						if (ev.getType() == CollectionEvent.Type.DELETE && ev.getItem() == home)
						{
							for (Plugin plugin : homePlugins.get(home))
							{
								plugin.destroy();
							}
							homePlugins.remove(home);
							application.removeHomesListener(this);
						}
					}
				});
			}
			return plugins;
		}
		else
		{
			return Collections.emptyList();
		}
	}
	
	/**
	 * Returns <code>true</code> if a plug-in in the given file name already exists
	 * in the first plug-ins folder.
	 * @throws RecorderException if no plug-ins folder is associated to this manager.
	 */
	public boolean pluginExists(String pluginLocation) throws RecorderException
	{
		if (this.pluginFolders == null || this.pluginFolders.length == 0)
		{
			throw new RecorderException("Can't access to plugins folder");
		}
		else
		{
			String pluginFileName = new File(pluginLocation).getName();
			return new File(this.pluginFolders[0], pluginFileName).exists();
		}
	}
	
	/**
	 * Deletes the given plug-in <code>libraries</code> from managed plug-ins. 
	 * @since 4.0
	 */
	public void deletePlugins(List<Library> libraries) throws RecorderException
	{
		for (Library library : libraries)
		{
			for (Iterator<Map.Entry<String, PluginLibrary>> it = this.pluginLibraries.entrySet().iterator(); it
					.hasNext();)
			{
				String pluginLocation = it.next().getValue().getLocation();
				if (pluginLocation.equals(library.getLocation()))
				{
					if (new File(pluginLocation).exists() && !new File(pluginLocation).delete())
					{
						throw new RecorderException("Couldn't delete file " + library.getLocation());
					}
					it.remove();
				}
			}
		}
	}
	
	/**
	 * Adds the file at the given location to the first plug-ins folders if it exists.
	 * Once added, the plug-in will be available at next application start. 
	 * @throws RecorderException if no plug-ins folder is associated to this manager.
	 */
	public void addPlugin(String pluginPath) throws RecorderException
	{
		try
		{
			if (this.pluginFolders == null || this.pluginFolders.length == 0)
			{
				throw new RecorderException("Can't access to plugins folder");
			}
			String pluginFileName = new File(pluginPath).getName();
			File destinationFile = new File(this.pluginFolders[0], pluginFileName);
			
			// Copy furnitureCatalogFile to furniture plugin folder
			InputStream tempIn = null;
			OutputStream tempOut = null;
			try
			{
				tempIn = new BufferedInputStream(new FileInputStream(pluginPath));
				this.pluginFolders[0].mkdirs();
				tempOut = new FileOutputStream(destinationFile);
				byte[] buffer = new byte[8192];
				int size;
				while ((size = tempIn.read(buffer)) != -1)
				{
					tempOut.write(buffer, 0, size);
				}
			}
			finally
			{
				if (tempIn != null)
				{
					tempIn.close();
				}
				if (tempOut != null)
				{
					tempOut.close();
				}
			}
		}
		catch (IOException ex)
		{
			throw new RecorderException("Can't write " + pluginPath + " in plugins folder", ex);
		}
	}
	
	/**
	 * The properties required to instantiate a plug-in.
	 */
	private static class PluginLibrary implements Library
	{
		private final String location;
		private final String name;
		private final String id;
		private final String description;
		private final String version;
		private final String license;
		private final String provider;
		private final Class<? extends Plugin> pluginClass;
		private final ClassLoader pluginClassLoader;
		
		/**
		 * Creates plug-in properties from parameters. 
		 */
		public PluginLibrary(String location, String id, String name, String description, String version,
				String license, String provider, Class<? extends Plugin> pluginClass, ClassLoader pluginClassLoader)
		{
			this.location = location;
			this.id = id;
			this.name = name;
			this.description = description;
			this.version = version;
			this.license = license;
			this.provider = provider;
			this.pluginClass = pluginClass;
			this.pluginClassLoader = pluginClassLoader;
		}
		
		public Class<? extends Plugin> getPluginClass()
		{
			return this.pluginClass;
		}
		
		public ClassLoader getPluginClassLoader()
		{
			return this.pluginClassLoader;
		}
		
		public String getType()
		{
			return PluginManager.PLUGIN_LIBRARY_TYPE;
		}
		
		public String getLocation()
		{
			return this.location;
		}
		
		public String getId()
		{
			return this.id;
		}
		
		public String getName()
		{
			return this.name;
		}
		
		public String getDescription()
		{
			return this.description;
		}
		
		public String getVersion()
		{
			return this.version;
		}
		
		public String getLicense()
		{
			return this.license;
		}
		
		public String getProvider()
		{
			return this.provider;
		}
	}
}
